package com.simen.hellobytes.http;

import com.simen.hellobytes.IOUtil;
import com.simen.hellobytes.StringUtil;
import com.simen.hellobytes.URLUtil;

import javax.net.ssl.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

public class HttpRequest implements HttpConstant, HttpBuilder {

    static boolean DEBUG = false;

    public static void setDEBUG(boolean DEBUG) {
        HttpRequest.DEBUG = DEBUG;
    }

    public static final String CACHE_FILE_SUFFIX = "_httpdown_cache";

    private boolean thisPrintLog = false;

    private String fileUrl;
    private int connectTimeout = CONNECT_TIMEOUT;
    private int readTimeout = READ_TIMEOUT;
    private Map<String, String> requestHeaders;

    private Map<String, List<String>> responseHeaders;
    private long contentLength;
    private boolean acceptRange = false;
    private String guessFileName;
    private String contentType;
    private String sourceContentType;
    /**
     * An int representing the three digit HTTP Status-Code.
     * 1xx: Informational
     * 2xx: Success
     * 3xx: Redirection
     * 4xx: Client Error
     * 5xx: Server Error
     */
    private int httpCode;
    private String statusMsg;
    private boolean instanceFollowRedirects = false;

    private boolean trustAllCert = false;
    private String protocols;
    private String location;
    private String contentLocation;

    private InputStream errorStream;
    private String localFilePath;
    private HttpDownloadProgressListener progressListener;
    private HttpURLConnection urlConnection = null;
    private boolean isRunning = false;
    /**
     * 是否已开始网络连接请求(URLConnection)
     */
    private boolean hasStartConnect = false;
    /**
     * true：关闭连接（可能会导致下次连接同一地址耗费时间变长）
     * https://docs.oracle.com/javase/6/docs/technotes/guides/net/http-keepalive.html
     */
    private boolean disConnectFinally = false;

    /**
     * false:使用用户指定的路径作为下载文件的存放路径
     * true:新建别名文件作为下载文件的存放路径,等到下载完成后再将此缓存文件重名为用户指定的文件路径
     */
    private boolean cacheFileBeforeComplete = false;

    private InputStream inputStream = null;
    private OutputStream outputStream = null;
    private FileOutputStream fileOutputStream = null;
    private HttpRequest redirectDownload = null;
    private File cacheFile;
    private HttpDownloadListener httpDownloadListener;
    private byte[] response;
    private byte[] error;
    private HttpMethod httpMethod;
    private byte[] header50Byte;
    private int header50Length;
    private byte[] requestBody;
    private String outputStreamCharset;
    private HttpStreamListener httpStreamListener;

    public static HttpBuilder connect(String fileUrl) {
        return new HttpRequest(fileUrl);
    }

    public HttpRequest() {
    }

    public HttpRequest(String fileUrl) {
        this(fileUrl, null);
    }

    public HttpRequest(String fileUrl, String localFilePath) {
        this(fileUrl, localFilePath, null, null);
    }

    public HttpRequest(String fileUrl, String localFilePath, HttpDownloadProgressListener progressListener) {
        this(fileUrl, localFilePath, null, progressListener);
    }

    public HttpRequest(String fileUrl, String localFilePath, Map<String, String> requestHeaders) {
        this(fileUrl, localFilePath, requestHeaders, null);
    }

    public HttpRequest(String fileUrl,
                       String localFilePath,
                       Map<String, String> requestHeaders,
                       HttpDownloadProgressListener progressListener) {
        this(fileUrl, localFilePath, requestHeaders, false, progressListener);
    }

    public HttpRequest(String fileUrl,
                       String localFilePath,
                       Map<String, String> requestHeaders,
                       boolean trustAllCert,
                       HttpDownloadProgressListener progressListener) {
        this(fileUrl, localFilePath, requestHeaders, trustAllCert, "TLS", progressListener);
    }

    /**
     * @param protocols TLS,SSL
     */
    public HttpRequest(String fileUrl,
                       String localFilePath,
                       Map<String, String> requestHeaders,
                       boolean trustAllCert,
                       String protocols,
                       HttpDownloadProgressListener progressListener) {
        this.fileUrl = fileUrl;
        this.localFilePath = localFilePath;
        this.requestHeaders = requestHeaders;
        this.trustAllCert = trustAllCert;
        this.protocols = protocols;
        this.progressListener = progressListener;
    }

    @Override
    public HttpBuilder download() throws IOException {
        if (!URLUtil.isNetworkUrl(fileUrl)) {
            throw new IOException("not support this url: " + fileUrl);
        }

        isRunning = true;
        hasStartConnect = false;

        if (!StringUtil.isTrimEmpty(localFilePath)) {
            cacheFile = new File(cacheFileBeforeComplete ? (localFilePath + CACHE_FILE_SUFFIX) : localFilePath);
        }

        try {
            if (DEBUG || thisPrintLog) {
                System.out.println("- - - - -Start ");
            }

            urlConnection = (HttpURLConnection) new URL(fileUrl).openConnection();
            if (urlConnection instanceof HttpsURLConnection && trustAllCert) {
                //设置信任所有证书
                if (DEBUG || thisPrintLog) System.out.println("will trust all certificates");
                trustAllCert((HttpsURLConnection) urlConnection);
            }
            initUrlConnection();

            if (urlConnection.getDoOutput()) {
                outputStream = urlConnection.getOutputStream();
                if (requestBody != null && requestBody.length > 0) {
                    outputStream.write(requestBody);
                }
            }
            httpCode = urlConnection.getResponseCode();
            hasStartConnect = true;
            statusMsg = urlConnection.getResponseMessage();
            responseHeaders = urlConnection.getHeaderFields();

            if (DEBUG || thisPrintLog) {
                String prefix = "- - - - -";
                System.out.println(prefix + httpCode + "/" + statusMsg + " " + fileUrl);
                if (responseHeaders != null) {
                    Iterator<Map.Entry<String, List<String>>> iterator = responseHeaders.entrySet().iterator();

                    while (iterator.hasNext()) {
                        Map.Entry<String, List<String>> entry = iterator.next();

                        String header = entry.getKey();
                        List<String> values = entry.getValue();

                        for (int i = 0; i < values.size(); i++) {
                            System.out.println(prefix + header + ": " + values.get(i));
                        }
                    }
                }
            }

            checkContentRange();
            checkContentType();
            checkContentLength();
            guessFileName(fileUrl);

            try {
                inputStream = urlConnection.getInputStream();
            } catch (IOException e) {
                errorStream = urlConnection.getErrorStream();
            }

            if (httpStreamListener != null) {
                httpStreamListener.onGetHttpStream(this, inputStream);
            } else if (cacheFile != null) {
                if (httpCode == 200) {
                    copyStreamToFile();
                } else {
                    throw new IOException("httpcode: " + httpCode);
                }
            } else if (httpDownloadListener != null) {
                if (httpCode == 200) {
                    response = IOUtil.readStream(inputStream);

                    httpDownloadListener.onHttpRequestSuccess(this);
                } else {
                    throw new IOException("httpcode: " + httpCode);
                }
            } else {
                if (inputStream != null) {
                    response = IOUtil.readStream(inputStream);
                } else if (errorStream != null) {
                    error = IOUtil.readStream(errorStream);
                }
            }
        } catch (IOException e) {
            // Read all data to allow reuse connection (https://docs.oracle.com/javase/6/docs/technotes/guides/net/http-keepalive.html)
            error = errorStream != null ? IOUtil.readStream(errorStream) : null;
            response = inputStream != null ? IOUtil.readStream(inputStream) : null;

            if (DEBUG || thisPrintLog) {
                e.printStackTrace();
            }

            if (httpDownloadListener != null) {
                httpDownloadListener.onHttpRequestFail(this, e);
            } else {
                throw new IOException(e);
            }
        } finally {
            closeImmediately();

            hasStartConnect = false;
            isRunning = false;
        }

        return this;
    }

    private void copyStreamToFile() throws IOException {
        byte[] buf = new byte[1024];
        int read = 0;
        long fileL = urlConnection.getContentLength();
        long readL = 0L;

        fileOutputStream = new FileOutputStream(cacheFile);

        while (isRunning && (read = inputStream.read(buf)) != -1) {
            fileOutputStream.write(buf, 0, read);
            if (header50Length < 50) {
                if (header50Byte == null) {
                    header50Byte = new byte[50];
                }
                int remain = 50 - header50Length;
                int readHeaderLength = (read >= remain) ? remain : read;
                System.arraycopy(buf, 0, header50Byte, header50Length, readHeaderLength);
                header50Length += readHeaderLength;
            }

            readL += read;

            if (progressListener != null) {
                progressListener.progress(fileUrl, fileL, readL);
            }

            if (DEBUG || thisPrintLog) {
                double progress = ((int) (readL * 1.0 / fileL * 1000) / 10D);
                Console c = System.console();
                if (c != null) {
                    c.printf("\rprogress " + progress);
                } else {
                    //System.out.println("progress " + progress);
                }
            }
        }

        if (cacheFileBeforeComplete) {
            File realFile = new File(localFilePath);

            if (!cacheFile.renameTo(realFile)) {
                throw new IOException("download complete,but rename it fail!!!");
            }
        }
    }

    private void initUrlConnection() throws ProtocolException {
        String httpMethodName = httpMethod == null ? HttpMethod.GET.getMethod() : httpMethod.getMethod();
        urlConnection.setConnectTimeout(connectTimeout);
        urlConnection.setReadTimeout(readTimeout);
        urlConnection.setRequestMethod(httpMethodName);
        urlConnection.setUseCaches(false);
        urlConnection.setAllowUserInteraction(false);//如果为true，则在允许用户交互（如弹出身份验证对话框）的意义下检查该URL。如果为false，则不允许用户交互。
        urlConnection.setInstanceFollowRedirects(instanceFollowRedirects);

        if (requestHeaders != null) {
            Iterator<Map.Entry<String, String>> iterator = requestHeaders.entrySet().iterator();

            while (iterator.hasNext()) {
                Map.Entry<String, String> entry = iterator.next();

                urlConnection.addRequestProperty(entry.getKey(), entry.getValue());
            }
        }

        urlConnection.setDoInput(true);
        urlConnection.setDoOutput(HttpMethod.POST.getMethod().equals(httpMethodName));

        if (DEBUG || thisPrintLog) {
            String prefix = "- - - - -";
            System.out.println(prefix + httpMethodName + " " + fileUrl);
            if (requestHeaders != null) {
                Iterator<Map.Entry<String, String>> iterator = requestHeaders.entrySet().iterator();

                while (iterator.hasNext()) {
                    Map.Entry<String, String> entry = iterator.next();

                    System.out.println(prefix + entry.getKey() + ": " + entry.getValue());
                }
            }
            System.out.println(prefix + "End " + httpMethodName);
        }
    }

    @Override
    public boolean isRunning() {
        return isRunning;
    }

    @Override
    public HttpBuilder setRunning(boolean running) {
        isRunning = running;

        if (redirectDownload != null) {
            redirectDownload.setRunning(running);
        }
        return this;
    }

    @Override
    public HttpBuilder closeImmediately() {
        try {
            if (hasStartConnect && disConnectFinally) {
                urlConnection.disconnect();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        IOUtil.closeSilently(fileOutputStream);
        IOUtil.closeSilently(outputStream);
        IOUtil.closeSilently(inputStream);
        IOUtil.closeSilently(errorStream);
        return this;
    }

    @Override
    public String getLocalFilePath() {
        return localFilePath;
    }

    @Override
    public HttpBuilder setLocalFilePath(String localFilePath) {
        this.localFilePath = localFilePath;
        return this;
    }

    @Override
    public HttpDownloadProgressListener getProgressListener() {
        return progressListener;
    }

    @Override
    public HttpBuilder setProgressListener(HttpDownloadProgressListener progressListener) {
        this.progressListener = progressListener;
        return this;
    }

    @Override
    public HttpBuilder setCacheFileBeforeComplete(boolean cacheFileBeforeComplete) {
        this.cacheFileBeforeComplete = cacheFileBeforeComplete;
        return this;
    }

    @Override
    public boolean isCacheFileBeforeComplete() {
        return cacheFileBeforeComplete;
    }

    @Override
    public HttpBuilder setHttpDownloadListener(HttpDownloadListener httpDownloadListener) {
        this.httpDownloadListener = httpDownloadListener;
        return this;
    }

    @Override
    public HttpDownloadListener getHttpDownloadListener() {
        return httpDownloadListener;
    }

    @Override
    public InputStream getErrorStream() {
        return errorStream;
    }

    @Override
    public byte[] getResponse() {
        return response;
    }

    @Override
    public byte[] getError() {
        return error;
    }

    @Override
    public String stringResponse() throws IOException {
        try {
            if (response != null) {
                return new String(response, 0, response.length, outputStreamCharset == null ? "UTF-8" : outputStreamCharset);
            } else if (error != null) {
                return new String(error, 0, error.length, outputStreamCharset == null ? "UTF-8" : outputStreamCharset);
            }
        } catch (UnsupportedEncodingException e) {
            throw new IOException(e);
        }
        return null;
    }

    @Override
    public HttpMethod getHttpMethod() {
        return httpMethod;
    }

    @Override
    public HttpBuilder setHttpMethod(HttpMethod httpMethod) {
        this.httpMethod = httpMethod;
        return this;
    }

    @Override
    public byte[] getHeader50Byte() {
        return header50Byte;
    }

    @Override
    public int getHeader50Length() {
        return header50Length;
    }

    @Override
    public HttpBuilder setOutputStreamCharset(String outputStreamCharset) {
        this.outputStreamCharset = outputStreamCharset;
        return this;
    }

    @Override
    public String getOutputStreamCharset() {
        return outputStreamCharset;
    }

    @Override
    public HttpBuilder setHttpDownloadStreamListener(HttpStreamListener httpStreamListener) {
        this.httpStreamListener = httpStreamListener;
        return this;
    }

    @Override
    public HttpStreamListener getHttpStreamListener() {
        return httpStreamListener;
    }

    @Override
    public HttpBuilder setDisConnectFinally(boolean disConnectFinally) {
        this.disConnectFinally = disConnectFinally;
        return this;
    }

    @Override
    public boolean isDisConnectFinally() {
        return disConnectFinally;
    }

    @Override
    public HttpBuilder startHeadRequest() throws IOException {
        HttpURLConnection urlConnection = null;

        try {
            urlConnection = (HttpURLConnection) new URL(fileUrl).openConnection();
            if (urlConnection instanceof HttpsURLConnection && trustAllCert) {
                //设置信任所有证书
                if (DEBUG || thisPrintLog) System.out.println("will trust all certificates");
                trustAllCert((HttpsURLConnection) urlConnection);
            }
            urlConnection.setConnectTimeout(connectTimeout);
            urlConnection.setReadTimeout(readTimeout);
            urlConnection.setUseCaches(false);
            urlConnection.setAllowUserInteraction(false);
            urlConnection.setInstanceFollowRedirects(instanceFollowRedirects);

            if (requestHeaders != null) {
                Iterator<Map.Entry<String, String>> iterator = requestHeaders.entrySet().iterator();
                while (iterator.hasNext()) {
                    Map.Entry<String, String> entry = iterator.next();
                    urlConnection.addRequestProperty(entry.getKey(), entry.getValue());
                }
            }

            urlConnection.setRequestMethod(HttpMethod.HEAD.getMethod());
            urlConnection.setDoInput(true);
            urlConnection.setDoOutput(false);

            httpCode = urlConnection.getResponseCode();
            statusMsg = urlConnection.getResponseMessage();
            responseHeaders = urlConnection.getHeaderFields();

            checkContentRange();
            checkContentType();
            checkContentLength();
            checkLocation();
            checkContentLocation();
            guessFileName(fileUrl);
        } finally {
            if (urlConnection != null) {
                try {
                    IOUtil.readAndCloseStream(urlConnection.getInputStream());
                } catch (IOException e) {
                    System.out.println("getInputStream fail." + e.getMessage());
                    IOUtil.readAndCloseStream(urlConnection.getErrorStream());
                }
            }
        }

        return this;
    }

    @Override
    public boolean isTrustAllCert() {
        return trustAllCert;
    }

    @Override
    public HttpBuilder setTrustAllCert(boolean trustAllCert) {
        this.trustAllCert = trustAllCert;
        return this;
    }

    @Override
    public String getProtocols() {
        return protocols;
    }

    @Override
    public HttpBuilder setProtocols(String protocols) {
        this.protocols = protocols;
        return this;
    }

    protected void trustAllCert(HttpsURLConnection httpsURLConnection) throws IOException {
        httpsURLConnection.setHostnameVerifier(new HostnameVerifier() {
            public boolean verify(String s, SSLSession sslSession) {
                return true;
            }
        });
        TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {

            public X509Certificate[] getAcceptedIssuers() {
                return new X509Certificate[0];
            }

            public void checkClientTrusted(X509Certificate[] chain, String authType) {
                // Intentionally left blank
            }

            public void checkServerTrusted(X509Certificate[] chain, String authType) {
                // Intentionally left blank
            }
        }};

        try {
            SSLContext context = SSLContext.getInstance(protocols == null ? "TLS" : protocols);
            context.init(null, trustAllCerts, new SecureRandom());

            httpsURLConnection.setSSLSocketFactory(context.getSocketFactory());
        } catch (GeneralSecurityException e) {
            throw new IOException(e);
        }
    }

    @Override
    public Map<String, String> getRequestHeaders() {
        return requestHeaders;
    }

    @Override
    public HttpBuilder setRequestHeaders(Map<String, String> requestHeaders) {
        if (requestHeaders == null) {
            this.requestHeaders = null;
            return this;
        }

        if (this.requestHeaders == null) this.requestHeaders = new IdentityHashMap<String, String>();
        this.requestHeaders.clear();
        if (requestHeaders.isEmpty()) return this;
        this.requestHeaders.putAll(requestHeaders);
        return this;
    }

    @Override
    public HttpBuilder addRequestHeader(String header, String value) {
        //use IdentityHashMap,map can contains multi same header.read https://stackoverflow.com/questions/40328504/what-is-the-point-of-urlconnection-getheaderfields-returning-a-mapstring-list?answertab=votes#tab-top
        if (requestHeaders == null) requestHeaders = new IdentityHashMap<String, String>();
        requestHeaders.put(header, value);
        return this;
    }

    @Override
    public HttpBuilder setHeadRange(long startPos) {
        return setHeadRange(startPos, -1);
    }

    @Override
    public HttpBuilder setHeadRange(long startPos, long endPos) {
        setHeadRanges(startPos, endPos);
        return this;
    }

    @Override
    public HttpBuilder setHeadRanges(long... pos) {
        String range = null;
        for (int i = 0; i < pos.length; i += 2) {
            if (range == null) {
                range = "bytes=" + pos[i] + "-";
            } else {
                range += "," + pos[i] + "-";
            }

            if (i + 1 < pos.length) {
                range += (pos[i + 1] == -1 ? "" : pos[i + 1]);
            } else {
                break;
            }
        }

        if (range != null) addRequestHeader(HEAD_RANGE, range);
        return this;
    }

    @Override
    public String getFileUrl() {
        return fileUrl;
    }

    @Override
    public HttpBuilder setFileUrl(String fileUrl) {
        this.fileUrl = fileUrl;
        return this;
    }

    @Override
    public HttpBuilder setInstanceFollowRedirects(boolean instanceFollowRedirects) {
        this.instanceFollowRedirects = instanceFollowRedirects;
        return this;
    }

    @Override
    public boolean isInstanceFollowRedirects() {
        return instanceFollowRedirects;
    }

    @Override
    public HttpBuilder setConnectTimeout(int connectTimeout) {
        this.connectTimeout = connectTimeout;
        return this;
    }

    @Override
    public int getConnectTimeout() {
        return connectTimeout;
    }

    @Override
    public HttpBuilder setReadTimeout(int readTimeout) {
        this.readTimeout = readTimeout;
        return this;
    }

    @Override
    public int getReadTimeout() {
        return readTimeout;
    }

    @Override
    public Map<String, List<String>> getResponseHeaders() {
        return responseHeaders;
    }

    @Override
    public int getHttpCode() {
        return httpCode;
    }

    @Override
    public String getStatusMsg() {
        return statusMsg;
    }

    @Override
    public boolean acceptRange() {
        return acceptRange;
    }

    @Override
    public long getContentLength() {
        return contentLength;
    }

    @Override
    public String getGuessFileName() {
        return guessFileName;
    }

    @Override
    public String getContentDisposition() {
        if (responseHeaders == null) return null;

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_CONTENT_DISPOSITION);

        if (values == null || values.isEmpty()) {
            return null;
        }

        return HttpHeaderUtil.parseFileNameFromContentDisposition(values.get(0));
    }

    @Override
    public String getContentType() {
        return contentType;
    }

    @Override
    public String getSourceContentType() {
        return sourceContentType;
    }

    protected void checkContentRange() {
        if (responseHeaders == null) {
            acceptRange = false;
            return;
        }

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_ACCEPT_RANGES);
        if (values != null && values.size() > 0) {
            acceptRange = "bytes".equalsIgnoreCase(values.get(0));
        }
    }

    protected void checkContentLength() {
        if (responseHeaders == null) {
            contentLength = -1;
            return;
        }

        List<String> values = responseHeaders.get(HEAD_CONTENT_LENGTH.toLowerCase());
        contentLength = values == null ? -1 : (values.isEmpty() ? -1 : Long.parseLong(values.get(0)));
    }

    protected void checkContentType() {
        if (responseHeaders == null) {
            this.contentType = null;
            this.sourceContentType = null;
            return;
        }

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_CONTENT_TYPE);
        String contentType = values == null ? null : (values.isEmpty() ? null : values.get(0));

        this.contentType = HttpHeaderUtil.parseContentType(contentType);
        this.sourceContentType = contentType;
    }

    protected void guessFileName(String fileUrl) {
        if (responseHeaders == null) {
            guessFileName = null;
            return;
        }

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_CONTENT_DISPOSITION);
        String contentDisposition = values == null ? null : (values.isEmpty() ? null : values.get(0));
        guessFileName = URLUtil.guessFileName(fileUrl, contentDisposition);
    }

    protected void checkLocation() {
        if (responseHeaders == null) {
            location = null;
            return;
        }

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_LOCATION);
        location = values == null ? null : (values.isEmpty() ? null : values.get(0));
    }

    protected void checkContentLocation() {
        if (responseHeaders == null) {
            contentLocation = null;
            return;
        }

        List<String> values = HttpHeaderUtil.findHeaderFromMap(responseHeaders, HEAD_CONTENT_LOCATION);
        contentLocation = values == null ? null : (values.isEmpty() ? null : values.get(0));
    }

    @Override
    public String getLocation() {
        return location;
    }

    @Override
    public String getContentLocation() {
        return contentLocation;
    }

    @Override
    public boolean isThisPrintLog() {
        return thisPrintLog;
    }

    @Override
    public HttpBuilder setThisPrintLog(boolean thisPrintLog) {
        this.thisPrintLog = thisPrintLog;
        return this;
    }

    @Override
    public HttpBuilder setRequestBody(byte[] requestBody) {
        this.requestBody = requestBody;
        return this;
    }

    public byte[] getRequestBody() {
        return requestBody;
    }
}
